#!/usr/bin/python3
# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: t -*-

import os
import sys
import json
import glob
import stat
import errno
import email.utils
import signal
import asyncio
import logging
import uvicorn
import aiofiles.os
import starlette
import starlette.routing
import starlette.responses
import starlette.exceptions
import starlette.applications
import setproctitle


class AppKlaus:

    def __init__(self, msId, localDir):
        import klaus
        import a2wsgi

        repoList = glob.glob(localDir + "/*")
        assert (all(self._isBareGitRepo(x) for x in repoList))

        self._app = klaus.make_app(repoList, msId, disable_push=True)          # FIXME: should support push in future
        self._app = a2wsgi.WSGIMiddleware(self._app, workers=1)

    async def __call__(self, scope, receive, send):
        await self._app(scope, receive, send)

    @staticmethod
    def _isBareGitRepo(dir):
        import dulwich.repo
        import dulwich.errors

        try:
            return dulwich.repo.Repo(dir).bare
        except (dulwich.errors.NotGitRepository, dulwich.errors.InvalidGitRepository):
            return False


class AppStatic:

    class RedirectException(Exception):

        def __init__(self, newPath=None, append=None, pop=None):
            self.newPath = newPath
            self.append = append
            self.pop = pop

            if newPath is not None:
                assert self.append is None and self.pop is None
            elif self.append is not None:
                assert self.newPath is None and self.pop is None
            elif self.pop is not None:
                assert self.newPath is None and self.append is None
                assert isinstance(self.pop, int)
            else:
                assert False

    class NotModifiedResponse(starlette.responses.Response):
        NOT_MODIFIED_HEADERS = (
            "cache-control",
            "content-location",
            "date",
            "etag",
            "expires",
            "vary",
        )

        def __init__(self, headers):
            super().__init__(
                status_code=304,
                headers={name: value for name, value in headers.items() if name in self.NOT_MODIFIED_HEADERS},
            )

    def __init__(self, msId, localDir, bAutoIndex):
        assert os.path.isabs(localDir) and not localDir.endswith("/")

        self._baseDir = localDir
        self._bAutoIndex = bAutoIndex

    async def __call__(self, scope, receive, send):
        assert scope["type"] == "http"

        if scope["method"] not in ("GET", "HEAD"):
            raise starlette.exceptions.HTTPException(status_code=405)

        try:
            fn, fullfn = self._getPath(scope)
            st = await self._lookupPath(fn, fullfn, False)

            # file
            if stat.S_ISREG(st.st_mode):
                await self._fileResponse(fullfn, st, scope)(scope, receive, send)
                return

            # directory
            if stat.S_ISDIR(st.st_mode):
                indexFn = os.path.join(fn, "index.html")
                indexFullfn = os.path.join(fullfn, "index.html")
                st = await self._lookupPath(indexFn, indexFullfn, self._bAutoIndex)

                # 'index.html' is a file
                if st is not None and stat.S_ISREG(st.st_mode):
                    if not scope["path"].endswith("/"):
                        # directory URLs should redirect to always end in "/".
                        raise self.RedirectException(append="/")
                    await self._fileResponse(indexFullfn, st, scope)(scope, receive, send)
                    return

                # 'index.html' has an invalid file type, it also can't be a directory (behave like what apache or nginx does)
                if st is not None:
                    raise starlette.exceptions.HTTPException(status_code=404)

                # generate and return an 'index.html'
                if True:
                    if not scope["path"].endswith("/"):
                        # directory URLs should redirect to always end in "/".
                        raise self.RedirectException(append="/")

                    fileLinks = []
                    for localFn in sorted(await aiofiles.os.listdir(fullfn)):
                        if localFn.startswith("."):
                            # prevent hidden files in self._baseDir from being served
                            continue
                        localFullfn = os.path.join(fullfn, localFn)
                        bIsDir = os.path.isdir(localFullfn)
                        href = "%s/%s%s" % (scope["path"].rstrip('/'), localFn, "/" if bIsDir else "")
                        fileLinks.append('<li><a href="%s">%s%s</a></li>' % (href, localFn, "/" if bIsDir else ""))

                    # FIXME: here we should not use "fullfn"
                    s = f"""
                        <!DOCTYPE html>
                        <html>
                            <head>
                                <title>Directory listing for {fullfn}</title>
                            </head>
                            <body>
                                <h1>Directory listing for {fullfn}</h1>
                                <ul>
                                    {'\n                                    '.join(fileLinks)}
                                </ul>
                            </body>
                        </html>
                    """
                    s = s.replace("\n                        ", "\n")
                    s = s.strip().strip("\n") + "\n"

                    await starlette.responses.HTMLResponse(s)(scope, receive, send)
                    return

        except self.RedirectException as e:
            url = starlette.datastructures.URL(scope=scope)
            if e.newPath is not None:
                url = url.replace(path=e.newPath)
            elif e.append is not None:
                url = url.replace(path=url.path + e.append)
            elif e.pop is not None:
                url = url.replace(path=url.path[:e.pop*-1])
            else:
                assert False
            await starlette.responses.RedirectResponse(url=url)(scope, receive, send)
            return

        # invalid file type (behave like what apache or nginx does)
        raise starlette.exceptions.HTTPException(status_code=404)

    def _fileResponse(self, full_path, stat_result, scope, status_code=200):
        request_headers = starlette.datastructures.Headers(scope=scope)
        response = starlette.responses.FileResponse(full_path, status_code=status_code, stat_result=stat_result)

        try:
            if_none_match = request_headers["if-none-match"]
            etag = response.headers["etag"]
            if etag in [tag.strip(" W/") for tag in if_none_match.split(",")]:
                return self.NotModifiedResponse(response.headers)
        except KeyError:
            pass

        try:
            if_modified_since = email.utils.parsedate(request_headers["if-modified-since"])
            last_modified = email.utils.parsedate(response.headers["last-modified"])
            if if_modified_since is not None and last_modified is not None and if_modified_since >= last_modified:
                return self.NotModifiedResponse(response.headers)
        except KeyError:
            pass

        return response

    def _getPath(self, scope):
        # returns (fn, fullfn)
        # don't allow misbehaving clients to break out of self._baseDir
        # redirect if scope["path"] is not normalized
        #
        # example 1:
        #   scope["path"] == "/abc/"
        #   scope["root_path"] == "/abc"
        #   self._baseDir = "/var/abc"
        #   returns ("", "/var/abc")
        #
        # example 2:
        #   scope["path"] == "/abc/a/b"
        #   scope["root_path"] == "/abc"
        #   self._baseDir = "/var/abc"
        #   returns ("a/b", "/var/abc/a/b")
        #
        # example 3:
        #   scope["path"] == "/abc/../b"
        #   scope["root_path"] == "/abc"
        #   self._baseDir = "/var/abc"
        #   raise HTTPException(status_code=404) -- break out of self._baseDir
        #
        # example 4:
        #   scope["path"] == "/abc/a/../b"
        #   scope["root_path"] == "/abc"
        #   self._baseDir = "/var/abc"
        #   raise self.RedirectException("/b") -- scope["path"] is not normalized
        #

        assert scope["path"].startswith(scope["root_path"] + "/")   # scope["path"] will be redirected to (scope["root_path"] + "/") by starlette when it equals to scope["root_path"]

        path = scope["path"][len(scope["root_path"]) + 1:]
        path = os.path.join(self._baseDir, path)

        if path.endswith("/"):
            nPath = os.path.normpath(path) + "/"
        else:
            nPath = os.path.normpath(path)

        assert nPath != self._baseDir                               # nPath can equal to (self._baseDir + "/"), it won't equal to self._baseDir

        if nPath.startswith(self._baseDir + "/"):
            if nPath != path:
                raise self.RedirectException(newPath=os.path.join(scope["root_path"], nPath[len(self._baseDir) + 1:]))
            return (nPath[len(self._baseDir) + 1:], nPath)
        else:
            raise starlette.exceptions.HTTPException(status_code=404)

    async def _lookupPath(self, fn, fullfn, bReturnNoneWhenFileNotFound):
        if fn.startswith(".") or "/." in fn:
            # prevent hidden files in self._baseDir from being served
            raise starlette.exceptions.HTTPException(status_code=404)

        try:
            return await aiofiles.os.stat(fullfn)
        except FileNotFoundError:
            if bReturnNoneWhenFileNotFound:
                return None
            raise starlette.exceptions.HTTPException(status_code=404)
        except NotADirectoryError:
            # file URLs should redirect to always not end in "/".
            raise self.RedirectException(pop=1)
        except PermissionError:
            raise starlette.exceptions.HTTPException(status_code=401)
        except OSError as e:
            if e.errno == errno.ENAMETOOLONG:
                raise starlette.exceptions.HTTPException(status_code=404)    # filename is too long, so it can't be a valid static file
            raise e


class AppDefault:

    async def __call__(self, scope, receive, send):
        assert scope["type"] == "http"
        raise starlette.exceptions.HTTPException(status_code=403)    # we use 403 for all request that can't match any route


class RefreshCfg:

    pending = False

    lock = asyncio.Lock()

    @classmethod
    def doWork(cls):
        if not cls.pending:
            cls.pending = True
            asyncio.run(cls.refreshCfgFromCfgFile(False))

    @classmethod
    def scheduleWork(cls):
        if not cls.pending:
            cls.pending = True
            asyncio.run_coroutine_threadsafe(cls.refreshCfgFromCfgFile(True), asyncio.get_event_loop())

    @classmethod
    async def refreshCfgFromCfgFile(cls, bWait):
        global cfgFile
        global cfg
        global app

        if bWait:
            await asyncio.sleep(1)

        async with cls.lock:
            assert cls.pending
            cls.pending = False

            dataObj = None
            async with aiofiles.open(cfgFile, "r") as f:
                # config file content example:
                # {
                #     "ip": "127.0.0.1",
                #     "port": 8080,
                #     "logFile": "log.txt",
                #     "siteInfo": {
                #         "k": {
                #             "root": "/var/git",
                #             "klausDirList": [
                #                 "/var/git"
                #             ]
                #         },
                #         "f": {
                #             "root": "/var/data",
                #             "autoIndexDirList": [
                #                 "/var/data"
                #             ]
                #         },
                #         "o": {
                #             "root": "/var/data2"
                #         }
                #     }
                # }
                buf = await f.read()
                if buf == "":
                    raise Exception("no content in config file")
                dataObj = json.loads(buf)

            if "logFile" not in dataObj:
                raise Exception("no \"logFile\" in config file")
            if "ip" not in dataObj:
                raise Exception("no \"ip\" in config file")
            if "port" not in dataObj:
                raise Exception("no \"port\" in config file")

            if "siteInfo" not in dataObj:
                raise Exception("no \"siteInfo\" in config file")
            for siteName, info in dataObj["siteInfo"].items():
                if "root" not in info:
                    raise Exception("no \"root\" in \"siteInfo\" in config file")
                for key in info:
                    if key not in ["root", "autoIndexDirList", "klausDirList"]:
                        raise Exception("invalid key in \"siteInfo\" in config file")
                for d in info.get("autoIndexDirList", []):
                    if d != info["root"] and not d.startswith(info["root"] + "/"):
                        raise Exception("invalid dir in \"siteInfo/autoIndexDirList\" in config file")
                for d in info.get("klausDirList", []):
                    if d != info["root"] and not d.startswith(info["root"] + "/"):
                        raise Exception("invalid dir in \"siteInfo/klausDirList\" in config file")

            # key "logFile" is not changable
            if "logFile" not in cfg:
                cfg["logFile"] = dataObj["logFile"]
            else:
                assert cfg["logFile"] == dataObj["logFile"]

            # key "ip" is not changable
            if "ip" not in cfg:
                cfg["ip"] = dataObj["ip"]
            else:
                assert cfg["ip"] == dataObj["ip"]

            # key "port" is not changable
            if "port" not in cfg:
                cfg["port"] = dataObj["port"]
            else:
                assert cfg["port"] == dataObj["port"]

            # key "siteInfo" is changable
            # there must be no await in this block, to ensure atomicity to starlette
            if True:
                app.routes.clear()
                pathList = []

                for msId, info in dataObj["siteInfo"].items():
                    for d in info.get("klausDirList", []):
                        url = os.path.normpath(os.path.join("/", msId, os.path.relpath(d, info["root"])))
                        app.routes.append(starlette.routing.Mount(url, app=AppKlaus(msId, d)))
                        Util.listAppendUnique(pathList, url)

                for msId, info in dataObj["siteInfo"].items():
                    for d in info.get("autoIndexDirList", []):
                        url = os.path.normpath(os.path.join("/", msId, os.path.relpath(d, info["root"])))
                        app.routes.append(starlette.routing.Mount(url, app=AppStatic(msId, d, True)))
                        Util.listAppendUnique(pathList, url)

                for msId, info in dataObj["siteInfo"].items():
                    url = os.path.join("/", msId)
                    if url not in pathList:
                        app.routes.append(starlette.routing.Mount(url, app=AppStatic(msId, info["root"], False)))
                        pathList.append(url)


class Util:

    @staticmethod
    def listAppendUnique(tl, x):
        assert x not in tl
        tl.append(x)


def sigHandler(signum, frame):
    RefreshCfg.scheduleWork()


if __name__ == "__main__":
    setproctitle.setproctitle("mirrors-httpd")

    cfgFile = sys.argv[1]
    cfg = dict()

    app = starlette.applications.Starlette()
    app.router = starlette.routing.Router(default=AppDefault())

    RefreshCfg.doWork()

    uvLogConfig = uvicorn.config.LOGGING_CONFIG.copy()
    uvLogConfig["handlers"]["default"]["class"] = "logging.FileHandler"
    uvLogConfig["handlers"]["default"]["filename"] = cfg["logFile"]
    del uvLogConfig["handlers"]["default"]["stream"]
    uvLogConfig["handlers"]["access"]["class"] = "logging.FileHandler"
    uvLogConfig["handlers"]["access"]["filename"] = cfg["logFile"]
    del uvLogConfig["handlers"]["access"]["stream"]
    uvConfig = uvicorn.config.Config(app, host=cfg["ip"], port=cfg["port"], log_config=uvLogConfig)
    uvServer = uvicorn.server.Server(config=uvConfig)

    signal.signal(signal.SIGUSR1, sigHandler)
    uvServer.run()
